dockview + tanstack-virtual 가상 스크롤 어긋남 - DOM 재부착 문제
마지막 수정일 · 2026. 07. 29.
tags: - react - 가상화 - virtual-scroll - dockview - tanstack-virtual - 버그진단 created: 2026-07-29
TL;DR
DOM 노드를 다른 부모로 옮기는 레이아웃 라이브러리(dockview 등)에서 패널을 옮기면 브라우저가 스크롤 컨테이너의 scrollTop을 0으로 리셋하는데, 이때 scroll 이벤트를 안 쏨. 가상화기(TanStack Virtual 등)는 scroll 이벤트로만 오프셋을 갱신하니까 예전 오프셋 자리에 행을 계속 그림 → 뷰포트엔 빈 스페이서만 → 백지. 클릭·휠로 첫 이벤트가 나면 0으로 동기화되며 살아나는데 위치는 맨 위임.
해결방법: 재부착/재표시 시점을 잡아서 가상화기와 스크롤 위치를 다시 동기화하면 됨. 셋 중 하나.
- 레이아웃 라이브러리의 "보이게 됨 / 위치 바뀜" 이벤트(dockview면
onDidVisibilityChange,onDidLocationChange)로measure()+ 스크롤 복원 - 스크롤 엘리먼트 rect가 0 → 양수로 돌아오는 순간을 리스트 내부에서 감지해 마지막 오프셋 복원 (라이브러리 비의존)
- 스크롤 위치를 패널 단위로 저장·복원 (팝아웃처럼 진짜 리마운트되는 케이스까지 커버, 제일 튼튼)
문제상황
패널을 옮기거나 탭을 바꾸고 돌아오면 가상 스크롤 리스트가 텅 비어 보임.
클릭하거나 휠을 굴리면 그제서야 행이 나타나는데, 원래 보던 위치가 아니라 맨 위에서 시작함.
dockview(도킹/패널), react-mosaic, golden-layout, 드래그하다 부모가 바뀌는 DnD, 팝아웃 창처럼 DOM 노드를 다른 부모로 옮기는 레이아웃 라이브러리와
TanStack Virtual / react-window / react-virtuoso처럼 scroll 이벤트로만 오프셋을 추적하는 가상화기를 같이 쓰면 나는 문제임.
라이브러리 조합이 뭐든 원리는 같음.
원인 1 : 레이아웃 라이브러리는 React 몰래 DOM을 옮김
보통 React 앱에서는 배치가 바뀌어도 엘리먼트가 문서 트리에서 통째로 빠지는 일이 드묾.
근데 이런 라이브러리들은 패널마다 콘텐츠 div를 하나 만들어 두고 React는 그 안에만 계속 렌더링하게 한 뒤,
배치는 자기가 순수 DOM 조작으로 그 div를 옮겨서 처리함. 탭 전환, 그룹 간 드래그, 팝아웃, 재도킹 전부 이 경로.
// dockview-core/dist/cjs/dockview/components/panel/content.js (renderPanel)
this._element.removeChild(this.panel.view.content.element); // 문서에서 뗌
// ... 새 그룹/팝아웃에서 ...
this._element.appendChild(this.panel.view.content.element); // 다시 붙임
여기에 숨겨진 탭/패널은 보통 display:none 처리까지 같이 들어감.
리마운트가 아니라 재부착이라는 게 포인트임. React 입장에선 아무 일도 안 일어난 거라 effect도 안 돌고 ref도 같은 객체 그대로임.
원인 2 : 브라우저는 분리됐던 엘리먼트의 스크롤을 안 지켜줌
브라우저는 문서에 붙어 있고 레이아웃이 있는 엘리먼트에 대해서만 스크롤 위치를 유지함.
문서에서 분리되는 순간 레이아웃이 사라지면서 스크롤 위치도 같이 버려지고, 다시 붙이면 scrollTop = 0부터 시작함.
결정적인 건 이 리셋이 scroll 이벤트를 발생시키지 않는다는 것.
사용자가 스크롤한 게 아니라 브라우저가 상태를 폐기한 거라서 밖에서 지켜보는 코드한테는 통보가 안 감.
원인 3 : 가상화기의 유일한 귀는 scroll 이벤트
가상화기는 스크롤 오프셋을 딱 한 경로로만 갱신함. 스크롤 엘리먼트에 붙인 scroll 리스너.
컨테이너 크기는 ResizeObserver로 따로 보지만 scrollTop 값 자체를 다시 읽어보는 감시자는 없음.
// @tanstack/virtual-core — observeElementOffset (요약)
const createHandler = (isScrolling) => () => {
offset = element.scrollTop; // scrollTop은 여기서만 읽힌다
cb(offset, isScrolling);
};
element.addEventListener("scroll", handler, { passive: true });
게다가 getScrollElement()가 같은 엘리먼트 인스턴스를 돌려주는 한 옵저버를 재초기화하지도 않음.
재부착은 리마운트가 아니라서 ref는 계속 같은 객체 → 재초기화 계기 자체가 없음 → 캐시된 옛날 오프셋이 그대로 살아남음.
어긋나는 순간
가상 리스트 구조는 전체 높이만큼의 스페이서 div 하나에 화면 근처 행 몇 개만 translateY(오프셋)으로 얹는 형태임.
즉 "어디쯤을 그릴지"는 가상화기가 기억하는 오프셋이 정하고, "어디가 보일지"는 실제 scrollTop이 정함.
이 둘이 어긋나면 겹치는 구간이 사라지고 화면엔 빈 스페이서만 남음.
DOM 분리 / display:none 동안에는 ResizeObserver가 0×0을 보고해서 rect가 0이 되는 것도 같이 겹침.
순서대로 보면
- 정상 상태 — 실제 scrollTop 3400, 가상화기 오프셋 3400. 일치
- 패널 조작 — 라이브러리가 콘텐츠 div를 removeChild → appendChild
- 브라우저가 scrollTop을 0으로 리셋. scroll 이벤트 없음 → 아무도 통보 못 받음 (실제 0 / 가상화기 3400)
- 가상화기는 3400 자리에 행을 그리는데 뷰포트는 0을 보여줌 → 백지
- 클릭·휠로 첫 scroll 이벤트 발생 → 오프셋이 0으로 동기화되며 행이 다시 나타남
- 근데 위치는 맨 위. 원래 자리로 보내주는 로직은 재실행 안 됨
왜 원래 위치로 안 돌아오나
"처음에 맨 아래로 보내주는" 류의 effect는 보통 마운트 시점 + 데이터 개수 변화에만 걸려 있음.
useEffect(() => {
if (flat.length > 0 && scrollToBottom && !newestFirst) { ... }
}, [flat.length, rowVirtualizer, scrollToBottom, newestFirst])
// 패널 조작으로는 어느 의존값도 안 변함
재부착은 리마운트도 아니고 데이터 변화도 아니라서 재실행 계기가 없음. 한 번 어긋나면 계속 맨 위에서 시작함.
추가로 진짜 리마운트가 일어나는 케이스(그리드↔팝아웃 이동, 레이아웃 clear() + fromJSON 복원)도 조심해야 함.
이때 effect가 setTimeout 몇십 ms 뒤에 돌면 그 시점엔 레이아웃 높이가 아직 0이라 scrollToIndex가 엉뚱한 오프셋을 계산함.
해결방법
원리는 하나. 재부착/재표시 시점을 감지해서 가상화기와 스크롤 위치를 다시 동기화하면 됨.
- 레이아웃 라이브러리 이벤트로 트리거 — dockview면
props.api.onDidVisibilityChange/onDidLocationChange같은 걸 리스트까지 내려보내서, 다시 보이는 순간measure()+ 스크롤 복원. 라이브러리마다 이름만 다르지 "보이게 됨 / 위치 바뀜" 이벤트는 대부분 있음 - rect 0 → 양수 전환을 리스트 내부에서 감지 — 라이브러리에 의존 안 하고 해결하는 방법. 스크롤 엘리먼트 크기가 0이었다가 실측값으로 돌아오는 순간을 훅으로 잡아서 마지막 오프셋 복원
- 스크롤 위치를 패널 단위로 저장·복원 — 팝아웃 이동처럼 진짜 리마운트가 일어나는 케이스까지 한 번에 커버됨. 제일 튼튼한 방향
scrollTop을 직접 되돌려도 됨. 그러면 scroll 이벤트가 나면서 가상화기도 따라 동기화되니까 레이아웃 높이가 잡힌 뒤에 하는 순서만 지키면 됨.
비슷한 문제 의심 체크리스트
- 리스트가 처음엔 멀쩡한데 레이아웃 조작 후에만 깨짐
- 클릭·휠 한 번이면 복구됨 (= 이벤트만 오면 되는 상태)
- 리마운트는 안 일어나는데(effect 로그 안 찍힘) 화면만 비어 있음
- 숨겼다 보여주는 탭 / 아코디언 / 모달 안의 가상 리스트에서 높이가 0으로 측정됨
이 중 두 개 이상이면 거의 이 패턴임.